interface CustomEasing {
  (t: number): number;
}

type Context = {
  scrollable: Element;
  method: (elapsed: number, value: number, x: number, y: number) => void;
  startTime: number;
  startX: number;
  startY: number;
  x: number;
  y: number;
  duration: number;
  ease: CustomEasing;
  cb: Function;
};

// Memoize so we're much more friendly to non-dom envs
let memoizedNow: () => number;
const now = () => {
  if (!memoizedNow) {
    memoizedNow = 'performance' in window ? performance.now.bind(performance) : Date.now;
  }
  return memoizedNow();
};

function step(context: Context) {
  const time = now();
  const elapsed = Math.min((time - context.startTime) / context.duration, 1);
  // apply easing to elapsed time
  const value = context.ease(elapsed);

  const currentX = context.startX + (context.x - context.startX) * value;
  const currentY = context.startY + (context.y - context.startY) * value;

  context.method(currentX, currentY, elapsed, value);

  // scroll more if we have not reached our destination
  if (currentX !== context.x || currentY !== context.y) {
    requestAnimationFrame(() => step(context));
  } else {
    // If nothing left to scroll lets fire the callback
    context.cb?.();
  }
}

type OnScrollChangeCallback = (scrollState: {
  target: Element;
  elapsed: number;
  value: number;
  left: number;
  top: number;
}) => void;

export function smoothScroll(
  el: Element,
  x: number,
  y: number,
  duration = 600,
  ease: CustomEasing = (t) => 1 + --t * t * t * t * t,
  cb?: Function,
  onScrollChange?: OnScrollChangeCallback,
) {
  // define scroll context
  const scrollable = el;
  const startX = el.scrollLeft;
  const startY = el.scrollTop;
  const method = (x: number, y: number, elapsed: number, value: number) => {
    // @TODO use Element.scroll if it exists, as it is potentially better performing
    // use ceil to include the the fractional part of the number for the scrolling
    const left = Math.ceil(x);
    const top = Math.ceil(y);

    el.scrollLeft = left;
    el.scrollTop = top;

    onScrollChange?.({
      target: el,
      elapsed,
      value,
      left,
      top,
    });
  };

  // scroll looping over a frame if needed
  step({
    scrollable: scrollable,
    method: method,
    startTime: now(),
    startX: startX,
    startY: startY,
    x: x,
    y: y,
    duration,
    ease,
    cb,
  });
}
